On this page

Skip to content

How to Use Vue 3 with ASP.NET Razor

Versions Used

Introduction

Vue 3 provides both "Composition API" and "Options API" styles. This article continues to use the "Options API" because the official Vue documentation mentions that many benefits of the "Composition API" only manifest in large-scale projects. For lightweight frontend implementations that reference JS files, the "Options API" is still recommended. It is definitely not because I don't understand the "Composition API" and don't want to learn it. The following is cited from the official documentation.

Which to Choose?

For production use:

Go with Options API if you are not using build tools, or plan to use Vue primarily in low-complexity scenarios, such as progressive enhancement.

Go with Composition API + Single-File Components if you plan to build full applications with Vue.

Will Options API be deprecated?#

No, we do not have any plan to do so. Options API is an integral part of Vue and the reason many developers love it. We also realize that many of the benefits of Composition API only manifest in larger-scale projects, and Options API remains a solid choice for many low-to-medium-complexity scenarios.

Architecture Overview

The architecture is adjusted based on the structure of the article "How to Use Vue with ASP.NET Razor". This article only provides the new code and will not repeat the previous explanations.

Code

_Layout.cshtml

Here, we will create the Vue object and configure the usage of other packages. Note the following points:

  • The name of the Vue component must be the same as the Tag name of the TagHelper, for example, VForm corresponds to the <v-form></v-form> generated by VeeValidateFormTagHelper.
  • VeeValidateFormTagHelper will generate the attribute :initial-errors="initialErrors()", which is used to call initialErrors to initialize error messages.
  • Please replace {Assembly Name} with the actual DLL assembly name; this will dynamically generate a style file specific to the assembly, with the content being the same as _Layout.cshtml.css.
html
<!DOCTYPE html>
<html lang="zh-Hant-TW">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/{Assembly Name}.styles.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    @RenderSection("Head", required: false)
</head>
<body>
    <div id="vueApp" class="container" v-cloak>
        @RenderBody()
    </div>
    <script src="~/lib/vue/vue.global.prod.js"></script>
    <script src="~/lib/popper/umd/popper.min.js"></script>
    <script src="~/lib/bootstrap/js/bootstrap.min.js"></script>
    <script src="~/lib/vee-validate/vee-validate.prod.min.js"></script>
    <script src="~/lib/vee-validate/rules/dist/vee-validate-rules.min.js"></script>
    <script src="~/lib/vee-validate/i18n/dist/vee-validate-i18n.min.js"></script>
    <script src="~/lib/axios/axios.min.js"></script>
    <script src="~/js/vee-validate-rules-extension.js"></script>
    <script src="~/js/site.js" asp-append-version="true"></script>
    <script>
        Object.keys(VeeValidateRules).forEach(rule => {
            if (rule !== 'default') {
                VeeValidate.defineRule(rule, VeeValidateRules[rule]);
            }
        });

        VeeValidateI18n.loadLocaleFromURL('@Url.Content("~/lib/vee-validate/i18n/dist/locale/zh_TW.json")');

        VeeValidate.configure({
            generateMessage: VeeValidateI18n.localize('zh_TW'),
        });

        let mixins = [];
    </script>

    @await RenderSectionAsync("Scripts", required: false)

    <script>
        let vueApp = Vue.createApp({
            components: {
                VForm: VeeValidate.Form,
                VField: VeeValidate.Field,
                VMessage: VeeValidate.ErrorMessage,
            },
            methods: {
                initialErrors() {
                    let initialErrors = {};

                    @foreach (var pair in ViewContext.ViewData.ModelState.Where(x => x.Value!.Errors.Any()))
                    {
                        <text>
                            initialErrors['@pair.Key'] = '@Html.Raw(pair.Value.Errors.First().ErrorMessage.Replace("'", "\\'"))';
                        </text>
                    }
                    return initialErrors
                }
            }
        })
        .use(VeeValidate);

        for (let i in mixins) {
            vueApp.mixin(mixins[i]);
        }

        vueApp.mount('#vueApp');
    </script>
</body>
</html>

vee-validate-rules-extension.js

This is to add validations that exist in jquery.validate.js but are missing in vee-validate-rules.js.

javascript
(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined'
        ? factory(exports)
        : typeof define === 'function' && define.amd
            ? define(['exports'], factory)
            : (global = typeof globalThis !== 'undefined'
                ? globalThis
                : global || self, factory(global.VeeValidateRules));
})(this, (function (exports) {
    'use strict';

    function isEmpty(value) {
        if (value === null || value === undefined || value === '') {
            return true;
        }
        if (Array.isArray(value) && value.length === 0) {
            return true;
        }
        return false;
    }

    function validateCreditCardRule(value) {
        let sum = 0;
        let digit;
        let tmpNum;
        let shouldDouble;

        let sanitized = value.replace(/[- ]+/g, '');

        for (let i = sanitized.length - 1; i >= 0; i--) {
            digit = sanitized.substring(i, i + 1);
            tmpNum = parseInt(digit, 10);

            if (shouldDouble) {
                tmpNum *= 2;

                if (tmpNum >= 10) {
                    sum += tmpNum % 10 + 1;
                } else {
                    sum += tmpNum;
                }
            } else {
                sum += tmpNum;
            }

            shouldDouble = !shouldDouble;
        }

        return !!(sum % 10 === 0 ? sanitized : false);
    }

    const creditCardValidator = (value) => {
        if (isEmpty(value)) {
            return true;
        }
        const re = /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|(222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\d{3})\d{11}|6[27][0-9]{14})$/;
        if (Array.isArray(value)) {
            return value.every(val => re.test(String(val)) && validateCreditCardRule(String(val)));
        }
        return re.test(String(value)) && validateCreditCardRule(String(val));
    };

    const urlValidator = (value) => {
        if (isEmpty(value)) {
            return true;
        }
        const re = /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})).?)(?::\d{2,5})?(?:[/?#]\S*)?$/i;
        if (Array.isArray(value)) {
            return value.every(val => re.test(String(val)));
        }
        return re.test(String(value));
    }

    /* eslint-disable camelcase */
    exports["default"].credit_card = creditCardValidator;
    exports["default"].url = urlValidator;

    exports.credit_card = creditCardValidator;
    exports.url = urlValidator;

    Object.defineProperty(exports, '__esModule', { value: true });

}));

site.css

Hide the original template before the component finishes compiling.

css
[v-cloak] {
    display: none;
}

site.js

Add RequestVerificationToken to the headers for ajax requests to perform ValidateAntiForgeryToken validation.

javascript
axios.interceptors.request.use(
    config => {
        let token = document.querySelector('input[name="__RequestVerificationToken"]');
        if (token !== null) {
            config.headers = {
                RequestVerificationToken: token.value
            }
        }
        return config;
    }
);

VeeValidateFormTagHelper

Used to generate <v-form></v-form>.

csharp
    [HtmlTargetElement("v-form", TagStructure = TagStructure.NormalOrSelfClosing)]
    public class VeeValidateFormTagHelper : FormTagHelper {
        private const string VueSlotAttributeName = "v-slot";

        public VeeValidateFormTagHelper(IHtmlGenerator generator) : base(generator) { }

        public override void Process(TagHelperContext context, TagHelperOutput output) {
            output.Attributes.Add(":initial-errors", "initialErrors()");

            if (!context.AllAttributes.ContainsName(VueSlotAttributeName)) {
                output.Attributes.Add(VueSlotAttributeName, "{ isSubmitting }");
            }

            base.Process(context, output);
        }
    }

VeeValidateInputTagHelper

Used to generate <v-field></v-field>, and output DataAnnotations like Required as rules="required" for vee-validate-rules.js to parse. The reason for choosing vee-validate.js as the frontend validation package is its support for parsing attributes to perform frontend validation.

csharp
[HtmlTargetElement("v-field", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
public class VeeValidateInputTagHelper : InputTagHelper {
    private const string ForAttributeName = "asp-for";
    private const string RulesAttributeName = "rules";
    private const string VueModelAttributeName = "v-model";

    public VeeValidateInputTagHelper(IHtmlGenerator generator) : base(generator) { }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        if (!context.AllAttributes.ContainsName(RulesAttributeName)) {
            string? rules = GetRules();
            if (rules != null) {
                output.Attributes.Add(RulesAttributeName, GetRules());
            }
        }

        base.Process(context, output);

        string[] excludeTypes = new string[] { "radio", "checkbox" };

        if (context.AllAttributes.ContainsName(VueModelAttributeName) && !excludeTypes.Contains(context.AllAttributes["type"].Value)) {
            output.Attributes.RemoveAt(output.Attributes.IndexOfName("value"));
        }
    }

    private string? GetRules() {
        List<string> items = new List<string>();

        if (For is not null) {
            foreach (var validationAttribute in For.Metadata.ValidatorMetadata) {
                switch (validationAttribute) {
                    case CompareAttribute attr:
                        // HACK Not sure if it can be captured correctly
                        string[] forNameParts = For.Name.Split('.');
                        forNameParts[^1] = attr.OtherProperty;
                        items.Add($"confirmed:@{string.Join(".", forNameParts)}");
                        break;
                    case CreditCardAttribute _:
                        items.Add("credit_card");
                        break;
                    case EmailAddressAttribute _:
                        items.Add("email");
                        break;
                    case FileExtensionsAttribute attr:
                        items.Add($"ext:{attr.Extensions}");
                        break;
                    case StringLengthAttribute attr:
                        if (attr.MaximumLength > 0) {
                            items.Add($"max:{attr.MaximumLength}");
                        }
                        if (attr.MinimumLength > 0) {
                            items.Add($"min:{attr.MinimumLength}");
                        }
                        break;
                    case MaxLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"max:{attr.Length}");
                        }
                        break;
                    case MinLengthAttribute attr:
                        if (attr.Length > 0) {
                            items.Add($"min:{attr.Length}");
                        }
                        break;
                    case RangeAttribute attr:
                        items.Add($"between:{attr.Minimum},{attr.Maximum}");
                        break;
                    case RequiredAttribute _:
                        items.Add("required");
                        break;
                    case UrlAttribute _:
                        items.Add("url");
                        break;
                }
            }
        }

        if (items.Any()) {
            return $"{string.Join("|", items)}";
        }

        return null;
    }
}

WARNING

vee-validate also uses <v-field></v-field> to generate <select></select>, but I haven't tested that yet.

VeeValidateMessageTagHelper

text-danger is used to match Bootstrap styles; please adjust it as needed.

csharp
[HtmlTargetElement("v-message", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
public class VeeValidateMessageTagHelper : TagHelper {
    private const string ForAttributeName = "asp-validation-for";

    [HtmlAttributeName(ForAttributeName)]
    public ModelExpression? For { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        if (context is null) {
            throw new ArgumentNullException(nameof(context));
        }

        if (output is null) {
            throw new ArgumentNullException(nameof(output));
        }

        if (For is null) {
            return;
        }

        output.Attributes.Add("name", For.Name);
        output.AddClass("text-danger", HtmlEncoder.Default);
    }
}

VueInputTagHelper

The primary generation of <input /> is still handled by the native InputTagHelper. The purpose here is to remove the value attribute when v-model is set, to avoid Vue warnings.

csharp
[HtmlTargetElement("input", Attributes = ForAttributeName, TagStructure = TagStructure.WithoutEndTag)]
public class VueInputTagHelper : TagHelper {
    private const string ForAttributeName = "asp-for";
    private const string VueModelAttributeName = "v-model";

    public override void Process(TagHelperContext context, TagHelperOutput output) {
        string[] excludeTypes = new string[] { "radio", "checkbox" };

        if (context.AllAttributes.ContainsName(VueModelAttributeName) && !excludeTypes.Contains(context.AllAttributes["type"].Value)) {
            output.Attributes.RemoveAt(output.Attributes.IndexOfName("value"));
        }
    }
}

_ViewImports.cshtml

Please replace {ProjectNamespace} with your project's namespace and {TagHelperNamespace} with the namespace of your custom TagHelpers. Note that since custom TagHelpers depend on native TagHelpers, the order cannot be swapped.

csharp
@namespace {ProjectNamespace}.Pages

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, {TagHelperNamespace}

Page.cshtml

  • Use <v-form></v-form> and <v-field></v-field> only if you need frontend field validation; otherwise, standard <form></form> and <input /> are sufficient.
  • isSubmitting is defined in VeeValidateFormTagHelper to disable the button after submission, preventing duplicate clicks.
html
<v-form method="post" asp-page="./Test">
    <input asp-for="Test.TestRequired" type="text"></v-field>
    <v-message asp-validation-for="Test.TestRequired"></v-message>
    <button type="submit" :disabled="isSubmitting">Submit</button>
    <button type="button" v-on:click="count++">{{ count }}</button>
</v-form>

@section Scripts {
    <script>
        let pageMixin = {
            data: function () {
                return {
                    count: 0
                };
            }
        }
        mixins.push(pageMixin);
    </script>
}

Conclusion

Actually, I really didn't want to use Vue Components, but since I couldn't find any other suitable frontend validation input field packages, I had to make do with it. This architecture is currently under testing, and I will update the content if any other issues arise.

WARNING

This article was written on 2023/01/30. Recently, while testing the project on 2024/04/06, I discovered that the <v-form></v-form> generated by VeeValidateFormTagHelper causes asp-page-handler to malfunction. Additionally, error messages cannot correctly display field names set by attributes like DisplayName.

I have no plans to resolve these issues for now and have decided to abandon this architecture. In the future, when writing Web applications, I might continue to choose Vue 2 with vee-validate 2, or consider using Vue 3 after ASP.NET Core drops its reliance on jQuery for frontend validation. Alternatively, considering that Vue 2 has reached end-of-life and I'm a bit tired of incompatibilities caused by frontend framework or package updates, I might just dive into ASP.NET Core Blazor =.=a.

Change Log

  • 2023-01-30 Initial version created.
  • 2024-04-07 Added notes on unresolved issues in the architecture.